TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { Suspense } from 'react';5import { PageHeader } from '@/components/ui/page-header';6import { Card, CardHeader, Delta, EmptyState, Skeleton, Stat, Badge, Unavailable } from '@/components/ui/primitives';7import { MarketTable } from '@/components/market/market-table';8import { AssetBrowser } from '@/components/market/asset-browser';9import { Thumb } from '@/components/market/bits';10import type { SP } from '@/lib/search-params';11import { AssetTable } from '@/components/market/asset-list';12import { SalesTable, ListingsTable } from '@/components/market/sales-table';13import { LotsIntelTable } from '@/components/market/lots-table';14import { LineChart } from '@/components/charts/line-chart';15import { BarChart } from '@/components/charts/bar-chart';16import { getBrandsInCategory, getCategoryRow, getCategorySeries, getMarketRows, getPopulationTrend, getSetsInCategory } from '@/lib/queries/markets';17import { rankedAssets, categoryScope } from '@/lib/queries/assets';18import { getRecentSalesInScope, getTopSales, listListings, listLots, listNews, listRadar } from '@/lib/queries/market-lists';19import { getIndex } from '@/lib/queries/indices';20import { fmtMoney, fmtNum, fmtRelative, fmtDate } from '@/lib/format';21import { categoryCrumbs, getCategory, humanize } from '@/lib/taxonomy';22import { CATEGORIES } from '@rareindex/taxonomy';2324export const revalidate = 300;2526export async function generateStaticParams() {27 return CATEGORIES.filter((c) => c.phase === 1).map((c) => ({ slug: c.slug }));28}2930export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {31 const { slug } = await params;32 const c = getCategory(slug);33 if (!c) return { title: 'Market not found' };34 return { title: `${c.name} market`, description: `${c.name} collectibles market on RareIndex: index, sales, volume, top movers, most valuable assets, listings and news.` };35}3637export default async function MarketPage({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise<SP> }) {38 const { slug } = await params;39 const sp = await searchParams;40 const row = await getCategoryRow(slug);41 if (!row) notFound();42 const { node, snapshot, counts } = row;43 const index = node.index ? await getIndex(node.index) : null;44 const subcategories = CATEGORIES.filter((c) => c.parent === slug);45 return (46 <div>47 <PageHeader48 crumbs={[{ label: 'Markets', href: '/markets' }, ...categoryCrumbs(slug)]}49 title={node.name}50 description={node.description ?? `${node.name} market data: verified sales, live listings, valuations and category index.`}51 meta={52 <>53 <Badge tone={node.phase === 1 ? 'index' : 'neutral'}>Phase {node.phase}</Badge>54 {index ? (55 <Link href={`/rareindex/${index.ticker}`} className="hover:text-fg">56 Subindex {index.ticker}57 </Link>58 ) : null}59 {node.compliance?.length ? <Badge tone="alert">{node.compliance.map(humanize).join(' · ')}</Badge> : null}60 <Link href={`/explore?category=${slug}`} className="hover:text-fg">61 Explore assets →62 </Link>63 </>64 }65 />6667 <section className="grid grid-cols-2 gap-px overflow-hidden rounded-md border border-border bg-border sm:grid-cols-4 lg:grid-cols-8">68 {[69 ['Tracked assets', fmtNum(counts.assets)],70 ['With valuation', fmtNum(counts.priced)],71 ['Sales (all time)', fmtNum(counts.sales)],72 ['Sales 30D', fmtNum(counts.sales30d)],73 ['Volume 30D', counts.volume30dUsd != null ? fmtMoney(counts.volume30dUsd, 'USD', { compact: true }) : '—'],74 ['Active listings', fmtNum(counts.listings)],75 ['Median RIV', counts.medianRivUsd != null ? fmtMoney(counts.medianRivUsd) : '—'],76 ['Market cap est.', snapshot?.marketCapEstUsd != null ? fmtMoney(snapshot.marketCapEstUsd, 'USD', { compact: true }) : '—'],77 ].map(([k, v]) => (78 <div key={k} className="bg-elevated px-3 py-2.5">79 <Stat label={k} value={v} />80 </div>81 ))}82 </section>83 {snapshot?.marketCapEstUsd != null ? <p className="mt-1 text-[11px] text-subtle">Market cap estimate = estimated population × representative RIV per asset, summed; indicative only (§124).</p> : null}84 {counts.assets > 0 && counts.priced === 0 ? (85 <p className="mt-2 rounded-md border border-border bg-sunken px-3 py-2 text-[12px] text-muted">86 <span className="font-medium text-fg">{fmtNum(counts.assets)} {node.name} assets are catalogued.</span> Valuations publish per asset once enough verified sales exist; guide prices from price-guide sources are shown in the meantime, labelled as such (never presented as market value).87 </p>88 ) : null}8990 <div className="mt-4">91 <Suspense fallback={<Skeleton className="h-[32rem]" />}>92 <AssetBrowser scope={{ category: slug }} sp={sp} basePath={`/markets/${slug}`} title={`Assets in ${node.name}`} subcategories={subcategories} />93 </Suspense>94 </div>9596 {counts.sales > 0 || counts.listings > 0 ? (97 <>98 <Suspense fallback={<Skeleton className="mt-4 h-72" />}>99 <IndexPanel slug={slug} />100 </Suspense>101102 <Suspense fallback={<Skeleton className="mt-4 h-72" />}>103 <Movers slug={slug} />104 </Suspense>105106 <Suspense fallback={<Skeleton className="mt-4 h-72" />}>107 <Activity slug={slug} />108 </Suspense>109 </>110 ) : (111 <Card className="mt-4 p-4">112 <p className="t-subtitle text-fg">Market analytics publish once verified sales or live listings exist in {node.name}.</p>113 <p className="mt-1 text-[13px] leading-relaxed text-muted">114 Movers, volume, records, value opportunities, auctions and the category index all derive from observed transactions. Until then the catalogue above is complete and guide prices are shown where a price-guide source reports them (labelled, never presented as market value).{' '}115 <Link href="/data" className="underline-offset-2 hover:text-fg hover:underline">116 How coverage grows →117 </Link>118 </p>119 </Card>120 )}121122 <Suspense fallback={<Skeleton className="mt-4 h-40" />}>123 <Structure slug={slug} />124 </Suspense>125 </div>126 );127}128129async function IndexPanel({ slug }: { slug: string }) {130 const series = await getCategorySeries(slug, 730);131 const points = series.filter((p) => p.indexValue != null).map((p) => ({ x: p.date, y: p.indexValue! }));132 const salesSeries = series.map((p) => ({ x: p.date, y: p.sales }));133 return (134 <section className="mt-4 grid gap-4 lg:grid-cols-[2fr_1fr]">135 <Card className="p-4">136 <h2 className="text-sm font-semibold">Category index</h2>137 <p className="text-xs text-muted">Daily category index from valuations of priced constituents; publishes once enough assets are priced.</p>138 <div className="mt-2">139 <LineChart ariaLabel={`${slug} category index`} height={220} series={[{ id: 'idx', label: 'Category index', kind: 'area', points }]} emptyLabel="Index building — not enough priced constituents yet" />140 </div>141 </Card>142 <Card className="p-4">143 <h2 className="text-sm font-semibold">Daily sales</h2>144 <p className="text-xs text-muted">Verified transactions per day (count)</p>145 <div className="mt-2">146 <LineChart ariaLabel={`${slug} daily sales`} height={220} series={[{ id: 'sales', label: 'Sales', kind: 'step', points: salesSeries }]} emptyLabel="No snapshot history yet" />147 </div>148 </Card>149 </section>150 );151}152153async function Movers({ slug }: { slug: string }) {154 const scope = categoryScope(slug);155 const [gainers, losers, volume, expensive, trending] = await Promise.all([156 rankedAssets('gainers', { scope, limit: 8, window: '7d' }),157 rankedAssets('losers', { scope, limit: 8, window: '7d' }),158 rankedAssets('volume', { scope, limit: 8 }),159 rankedAssets('expensive', { scope, limit: 8 }),160 rankedAssets('trending', { scope, limit: 8 }),161 ]);162 const block = (title: string, sub: string, items: typeof gainers, columns: Parameters<typeof AssetTable>[0]['columns']) => (163 <Card>164 <CardHeader title={title} subtitle={sub} />165 <AssetTable items={items} columns={columns} emptyTitle="No data yet" emptyDescription="Needs priced assets with sales history in this category." />166 </Card>167 );168 return (169 <section className="mt-4 grid gap-4 xl:grid-cols-2">170 {block('Top gainers · 7D', 'Assets with ≥3 sales used in valuation', gainers, ['riv', 'change7d', 'change30d', 'sales'])}171 {block('Top losers · 7D', 'Assets with ≥3 sales used in valuation', losers, ['riv', 'change7d', 'change30d', 'sales'])}172 {block('Highest volume · 30D', 'Sum of verified sale prices, USD', volume, ['riv', 'sales30d', 'change30d', 'liquidity'])}173 {block('Most valuable', 'Ranked by RareIndex Valuation', expensive, ['riv', 'latestSale', 'change1y', 'rarity'])}174 {block('Trending', 'Price × volume × listing momentum', trending, ['riv', 'trending', 'change7d', 'sales30d'])}175 <Card>176 <CardHeader title="Value opportunities" subtitle="Assets whose lowest ask sits materially below RIV — analytical data, not advice" />177 <AssetTable items={await rankedAssets('opportunity', { scope, limit: 8 })} columns={['riv', 'minAsk', 'opportunity', 'listings']} emptyTitle="No opportunities detected" emptyDescription="Requires both a valuation and active listings." />178 </Card>179 </section>180 );181}182183async function Activity({ slug }: { slug: string }) {184 const scope = categoryScope(slug);185 const [records, recent, listings, lots, news, radar, pop] = await Promise.all([186 getTopSales(6, scope),187 getRecentSalesInScope(scope, 10),188 listListings({ category: slug, pageSize: 10, sort: 'newest' }),189 listLots({ category: slug, limit: 6, sort: 'value' }),190 listNews({ category: slug, limit: 6 }),191 listRadar({ scope, limit: 6 }),192 getPopulationTrend(slug),193 ]);194 const popByGrader = new Map<string, Array<{ x: string; y: number }>>();195 for (const p of pop) (popByGrader.get(p.grader) ?? popByGrader.set(p.grader, []).get(p.grader)!).push({ x: p.reportDate, y: p.total });196 return (197 <section className="mt-4 grid gap-4 xl:grid-cols-2">198 <Card>199 <CardHeader title="Newest records" subtitle="Highest verified sales in this market" action={<Link href={`/records`} className="text-muted hover:text-fg">All records →</Link>} />200 <SalesTable items={records} emptyDescription="No verified sales in this category yet." />201 </Card>202 <Card>203 <CardHeader title="Latest sales" action={<Link href={`/sales?category=${slug}`} className="text-muted hover:text-fg">All sales →</Link>} />204 <SalesTable items={recent} emptyDescription="No sales in this category yet." />205 </Card>206 <Card className="xl:col-span-2">207 <CardHeader title="Live listings" subtitle="Asking prices are not market values" action={<Link href={`/listings?category=${slug}`} className="text-muted hover:text-fg">All listings →</Link>} />208 <ListingsTable items={listings.items} />209 </Card>210 <Card>211 <CardHeader title="Biggest auctions" subtitle="Lots by hammer, bid or high estimate" action={<Link href="/auctions" className="text-muted hover:text-fg">Auctions →</Link>} />212 <LotsIntelTable items={lots} />213 </Card>214 <Card>215 <CardHeader title="Population trends" subtitle="Total graded population reported per grader" />216 {popByGrader.size ? (217 <div className="p-4">218 <LineChart ariaLabel="Population trend" height={200} series={[...popByGrader.entries()].map(([g, pts]) => ({ id: g, label: g.toUpperCase(), points: pts }))} />219 </div>220 ) : (221 <EmptyState title="No population reports" description="Population data appears when grading-company connectors publish reports for assets in this category." className="py-8" />222 )}223 </Card>224 <Card>225 <CardHeader title="Rare Radar" action={<Link href="/radar" className="text-muted hover:text-fg">Open radar →</Link>} />226 {radar.length ? (227 <ul className="divide-y divide-border text-[12px]">228 {radar.map((r) => (229 <li key={r.id} className="flex items-center justify-between gap-3 px-4 py-2">230 <Link href={`/asset/${r.assetSlug}`} className="truncate font-medium text-fg hover:underline">231 {r.assetTitle}232 </Link>233 <span className="shrink-0 text-muted">234 {humanize(r.kind)} · {fmtRelative(r.detectedAt)}235 </span>236 </li>237 ))}238 </ul>239 ) : (240 <EmptyState title="Radar is quiet" className="py-8" />241 )}242 </Card>243 <Card>244 <CardHeader title="News" action={<Link href={`/news?category=${slug}`} className="text-muted hover:text-fg">All news →</Link>} />245 {news.items.length ? (246 <ul className="divide-y divide-border text-[12px]">247 {news.items.map((n) => (248 <li key={n.id} className="px-4 py-2">249 <a href={n.url} target="_blank" rel="noopener nofollow" className="font-medium text-fg hover:underline">250 {n.title}251 </a>252 <span className="block text-[11px] text-muted">253 {n.sourceName} · {n.publishedAt ? fmtDate(n.publishedAt) : 'undated'}254 </span>255 </li>256 ))}257 </ul>258 ) : (259 <EmptyState title="No news indexed" description="News connectors are not yet publishing for this market." className="py-8" />260 )}261 </Card>262 </section>263 );264}265266async function Structure({ slug }: { slug: string }) {267 const [children, sets, brands] = await Promise.all([getMarketRows(slug), getSetsInCategory(slug, 40), getBrandsInCategory(slug, 30)]);268 return (269 <section className="mt-4 grid gap-4">270 {children.length ? (271 <Card>272 <CardHeader title="Subcategories" />273 <MarketTable rows={children} showPhase />274 </Card>275 ) : null}276 <div className="grid gap-4 lg:grid-cols-2">277 <Card>278 <CardHeader title="Sets & releases" subtitle={`${fmtNum(sets.length)}${sets.length >= 40 ? '+' : ''} releases catalogued · ranked by sales, then size`} action={<Link href={`/markets/${slug}?sort=number#assets`} className="text-muted hover:text-fg">Browse by set →</Link>} />279 {sets.length ? (280 <ul className="grid grid-cols-1 text-[12px] sm:grid-cols-2">281 {sets.map((s) => (282 <li key={s.slug} className="flex items-center gap-2 border-b border-border px-4 py-1.5">283 <Thumb src={s.thumb} alt="" size={28} />284 <Link href={`/set/${s.slug}`} className="min-w-0 flex-1 truncate font-medium text-fg hover:underline">285 {s.name}286 {s.code ? <span className="ml-1 text-subtle">{s.code}</span> : null}287 {s.releaseYear ? <span className="ml-1 text-subtle">{s.releaseYear}</span> : null}288 </Link>289 <span className="num shrink-0 text-muted">290 {fmtNum(s.assets)}{s.sales ? ` · ${fmtNum(s.sales)} sales` : ''}{s.priced ? ` · ${fmtNum(s.priced)} priced` : ''}291 </span>292 </li>293 ))}294 </ul>295 ) : (296 <EmptyState title="No sets catalogued" className="py-8" />297 )}298 </Card>299 <Card>300 <CardHeader title="Brands" subtitle="Median RIV of priced assets" />301 {brands.length ? (302 <div className="p-4">303 <BarChart ariaLabel="Assets per brand" data={brands.slice(0, 12).map((b) => ({ label: b.brand, value: b.assets, sublabel: `${b.priced} priced · median RIV ${b.medianRivUsd != null ? fmtMoney(b.medianRivUsd) : '—'}`, href: `/brand/${encodeURIComponent(b.brand)}` }))} />304 </div>305 ) : (306 <EmptyState title="No brand data" className="py-8" />307 )}308 </Card>309 </div>310 <p className="text-[11px] text-subtle">311 Unavailable metrics render as <Unavailable /> rather than estimates (§192).312 </p>313 <span className="hidden">314 <Delta value={null} />315 </span>316 </section>317 );318}319